Skip to content

Identify CommandForm fields and columns by a tamper-resistant marker - #112

Open
woksin wants to merge 6 commits into
feat/table-rendering-seamfrom
feat/command-form-marker
Open

Identify CommandForm fields and columns by a tamper-resistant marker#112
woksin wants to merge 6 commits into
feat/table-rendering-seamfrom
feat/command-form-marker

Conversation

@woksin

@woksin woksin commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

CommandForm fields and columns are now identified by a marker a build transform cannot rewrite, with the legacy displayName kept as a permanent fallback.

Added

  • markAsCommandFormField and markAsCommandFormColumn for marking a hand-rolled field or column, and isCommandFormField and isCommandFormColumn for identifying one, exported from @cratis/components/CommandForm
  • CommandFormMarked, the marker shape shared with @cratis/arc.react, and the CommandFormFieldDisplayName and CommandFormColumnDisplayName constants
  • no-raw-command-form-marker rule in @cratis/eslint-plugin-components, flagging fields and columns identified by a hand-written displayName string
  • Documentation on how a CommandForm child is recognized as a field, and on the Storybook setting that breaks it

Changed

  • CommandDialog, CommandStepper and CommandDialog.Column identify children through the marker, falling back to displayName, so consumers marking a field by hand are unaffected

Fixed

  • A CommandForm field whose displayName is rewritten by a build transform — such as Storybook's reactDocgen: 'react-docgen-typescript', which rewrites it by default — is no longer silently unbound and rendered without its label, value binding and change handler

woksin and others added 6 commits August 4, 2026 09:19
The suite runs with `isolate: false`, so a module imported by one spec file
stays in the registry with that file's mocks bound in, and vitest's file order
is not stable between runs. Eight specs imported the module under test
statically and therefore passed or failed depending on which file happened to
load it first.

Measured on an unmodified tree: three of six consecutive `yarn test` runs were
red, failing in `Dialogs/for_Dialog/when_confirming_with_close_dialog_and_result`,
`for_CommandDialog/when_confirming_with_close_dialog_and_command_result` or
`for_toastCommandResult/when_toasting_a_command_result`. A separate symptom of
the same cause was an unhandled `commandInstance.execute is not a function`
rejection, raised when a CommandDialog bound to another file's auto-firing
Button mock reached a spec whose `useCommandInstance` returns no `execute`.
That failed `yarn ci` with every test still reporting green.

Each affected spec now re-evaluates the module under test inside `beforeEach`
after `vi.resetModules()`, which is the idiom `when_validity_is_gated` and
`when_step_has_field_errors` already used — those two never appeared in any
failure. Ten consecutive runs are now clean with no unhandled errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRCRgRvMz9N8P8NGwR34MM
…layName

A `CommandForm` child was classified as a field or a column by exactly one
test: `component.displayName === 'CommandFormField'` (or `'CommandFormColumn'`).
`displayName` is React's public, writable diagnostic name and a routine target
for build tooling, so any transform that sets it unbinds every field — with no
error, no warning and every gate green. The field then renders with no
container: no label, no bound value, no change handler.

This adds a marker that such a transform cannot reach, checked first, with the
`displayName` comparison kept as a fallback:

- `CommandFormFieldMarker` / `CommandFormColumnMarker` — `Symbol.for` registry
  keys, so `@cratis/arc.react` and `@cratis/components` resolve the same symbol
  without importing it from each other. A named import would be a hard
  module-link error against any version in this package's peer range that does
  not export it, and a plain `Symbol()` would give a duplicate install two keys
  that never compare equal.
- `isCommandFormField` / `isCommandFormColumn` — marker first, `displayName`
  second — now used at all three read sites (`CommandDialog`, and both reads in
  `CommandStepper`).
- `markAsCommandFormField` / `markAsCommandFormColumn` set the marker *and* the
  legacy `displayName`; `CommandDialog.Column` is stamped through the latter.

The `displayName` path is retained indefinitely rather than deprecated. It is
what lets these two independently versioned packages interoperate in both
directions, and what keeps working every consumer who marks a field by hand.
Removing it would reproduce the very failure this change prevents.

Purely additive: no public API is removed and no existing consumer changes
behaviour.

This is the consumer half of the contract. The field marker only takes effect
once `@cratis/arc.react` stamps it; until then every path here falls back to
`displayName` exactly as before. Because both sides keep the fallback, the two
packages may ship in either order without a skew hazard.

Also documents that `displayName` is load-bearing on field and column
components, including the Storybook `reactDocgen: 'react-docgen-typescript'`
default that rewrites it and the `setDisplayName: false` setting that disables
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRCRgRvMz9N8P8NGwR34MM
The first pass proved the marker path and left gaps around it. Adds:

- a component carrying *only* the marker and no displayName at all — the mirror
  of the legacy-only case, and the one that shows the marker is sufficient by
  itself rather than merely corroborating the label;
- what the marking helpers actually do — both identifiers set, no cross-marking,
  and the component returned is the one that was marked, since both call styles
  are used in this package;
- strictness of the check: a marker of `false`, or of a truthy non-boolean, is
  not a marker;
- a renamed field nested inside `CommandDialog.Column`, which reaches the field
  through `processChildren`' recursion rather than as a direct child — the
  arrangement the column API exists for;
- `Symbol.keyFor` assertions on marker identity. That distinguishes
  `Symbol.for('x')` from `Symbol('x')`, which nothing else about the value does,
  and names the key the other package has to use. Those key strings are the
  whole cross-package contract: changing one breaks it while every exported
  identifier stays the same.

Both directions are now mutation-proven. Reverting the predicates to
legacy-string-only reds 11 tests across 7 files; removing the legacy fallback
instead reds 7 across 5 — including the pre-existing
`when_step_has_field_errors`, which stamps the string on a fake to make it a
field and is exactly the canary for that breaking change.

Also documents that the helpers replace any existing `displayName`, which is
forced rather than incidental — an older Arc binds by that exact string — so a
component needing its own diagnostic label cannot also be marked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRCRgRvMz9N8P8NGwR34MM
The marker makes the right thing possible; this makes the wrong thing visible.
Consumers hand-roll `CommandForm` fields, and the failure mode being fixed is
silent — a renamed component simply stops being a field, with no error, no
warning and every gate green — so a lint rule is the only place it surfaces at
authoring time.

Flags identifying a field or column by a hand-written `displayName` string in
either direction: stamping it (assignment, computed access, or object-literal
form as in `Object.assign`) and comparing against it (`===`/`!==`, either
operand order, including the `(x as { displayName?: string })` cast form this
package itself used). Points at `markAsCommandFormField`/`markAsCommandFormColumn`
and `isCommandFormField`/`isCommandFormColumn`, naming the right helper for the
string that was written.

Referring to the exported `CommandFormFieldDisplayName` /
`CommandFormColumnDisplayName` constants is not flagged, so the declarations
themselves and any deliberate legacy-path code stay clean. Going through the
helpers is strictly more permissive than the literal, never less — they still
set and honour the legacy `displayName` — so the rule never trades compatibility
for safety.

Verified end to end through the ESLint Linter, not only RuleTester: all three
shapes this repo carried before the marker existed are reported, each naming the
correct helper. 21 rule tests added.

This repo's own eslint config does not load the plugin, so this changes no gate
here; it is published surface for consumers and is enabled in
`configs.recommended` alongside the existing four rules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRCRgRvMz9N8P8NGwR34MM
Two rules under .ai/rules were missed when this work was written.

American English (general.md, typescript.md "Language — American English
Only"): "recognise"/"recognised" become "recognize"/"recognized" across the
marker module, its specs, the ESLint rule and both docs pages. `Cancelled` is
left alone — that is the spelling of Arc's DialogResult member, an API name
rather than prose.

Spaces in it() descriptions (specs.typescript.md "Naming Conventions", where
it('should_return_invalid_result') is the explicit counter-example): every
it() in the new specs becomes a readable sentence. The pre-existing underscore
descriptions elsewhere are left untouched — they predate this work, and the
repo already runs 157 space-style descriptions against 57 underscore ones, so
the convention followed here is also the majority one.

No behavior change; identifiers, assertions and control flow are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRCRgRvMz9N8P8NGwR34MM
@cratis/arc.react marks fields and columns with `isCommandFormField` and
`isCommandFormColumn` boolean properties. This package had reached for
`Symbol.for` registry keys instead, and the two markers cannot see each other.

Nothing threw, because both sides kept the legacy `displayName` fallback — but
that is what hid the defect. The marker did nothing across the package
boundary, so a field whose `displayName` a build transform had rewritten still
bound in a bare `CommandForm` and silently unbound inside a `CommandDialog` or
`CommandStepper`: the exact failure the marker was added to prevent, surviving
the fix, with every spec in both packages passing.

Arc's shape wins because arc owns the contract — it defines `asCommandFormField`
and `CommandForm` — and because the argument for the Symbol does not hold up. A
plain property needs no cross-package import either, since either side can test
`isCommandFormField === true` locally, so it gives up none of the version
decoupling; and no build transform renames arbitrary static properties, only
`displayName`, which is the whole hazard.

`CommandFormMarked` is duplicated here rather than imported: the peer range on
@cratis/arc.react spans versions that do not export it, so a named import would
be a hard module-link error rather than a graceful degrade.

Adds the spec neither package had — a component marked the way arc marks one,
with its displayName then overwritten, is recognized here; and one marked here
carries the exact property names arc reads. Renaming either marker now reds
that spec, where before it changed nothing observable in either repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRCRgRvMz9N8P8NGwR34MM
@woksin

woksin commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Reviewer context — none of this is release-note material.

Marker shape, and why it changed mid-flight. The marker is isCommandFormField / isCommandFormColumn boolean properties, matching what @cratis/arc.react writes and reads. This package first reached for Symbol.for registry keys while arc independently chose the boolean, which made the two markers mutually invisible. Nothing threw, because both sides kept the displayName fallback — and that is exactly what hid it: every spec in both repositories passed while the marker quietly stopped crossing the package boundary, so a field whose displayName a build transform had rewritten still bound in a bare CommandForm and silently unbound inside a CommandDialog. The failure the marker was added to prevent, surviving the fix, invisible to every gate. Arc's shape wins because arc owns the contract (it defines asCommandFormField and CommandForm), and because a plain property needs no cross-package import either — either side can test isCommandFormField === true locally — so it gives up none of the version decoupling.

CommandFormMarked is duplicated rather than imported. The peer range on @cratis/arc.react spans versions that do not export it, so a named import would be a hard module-link error rather than a graceful degrade. The cost is that the contract is carried by two property names spelled identically in two repositories, with nothing in the compiler enforcing it — which is what the new when_exchanging_marked_components_with_arc spec exists to catch. Arc carries the mirror of it.

The displayName fallback should not get a deprecation path. It is what lets the two independently versioned packages interoperate in both directions, and this package's own when_step_has_field_errors spec stamps the string on a fake to make it a field.

Arc's half. Arc stamps the field marker at all four sites (two of which, RadioGroupField and RadioButtonField, bypass asCommandFormField and stamp directly), stamps the column, and reads marker-first at its four read sites. Both halves need to land for the cross-package case to be fixed; either can ship first, since both keep the fallback.

Verification. yarn ci green across repeated runs — 264 tests in @cratis/components, 53 in @cratis/eslint-plugin-components — plus build-storybook and markdownlint over Documentation/**. Mutation-proven three ways rather than asserted: reverting the predicates to legacy-string-only reds 15 tests across 7 files; removing the legacy fallback instead reds 9 across 6, including the pre-existing when_step_has_field_errors, which is the natural canary for that breaking change; and renaming the marker property — the shape drift described above — reds 4 across 3, where before it changed nothing observable in either repository. The ESLint rule was also verified end to end through the real Linter, not only RuleTester.

One internal fix is bundled (first commit, separable): eight dialog, stepper and toast specs no longer depend on spec file execution order. The suite runs with isolate: false, so a module imported by one file stayed cached with that file's mocks bound in — three of six consecutive runs failed on an unmodified tree, and a related unhandled commandInstance.execute is not a function rejection failed yarn ci while every test still reported green. They now use the vi.resetModules() + dynamic-import idiom two specs in the repo already used.

Open for a maintainer: whether a dev-build console.warn for a CommandForm that recognizes zero fields is wanted; and whether no-raw-command-form-marker belongs in configs.recommended — it is enabled there for consistency with the existing four rules, which surfaces new errors for consumers on upgrade.

No issue references in the description: the repository's issues could not be searched from this environment, and inventing or reusing a number is worse than omitting one. Happy to add them.

@woksin

woksin commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Pairs with Cratis/Arc#2443, which carries the other half of the marker contract. Either can merge first — both sides keep the legacy displayName, so a new version of one works against an old version of the other in both directions. The field half of this PR only becomes active once the Arc side ships.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant